fix(agent): 兼容 reasoning model#1357
Hidden character warning
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR improves compatibility with “reasoning” models by preventing streamed responses/thinking tags from leaking into user-visible content, and adds coverage for resource loading behavior.
Changes:
- Add
<think>...</think>filtering in OpenAI-stream parsing to route thought text intothinking_delta. - Adjust AgentProvider’s test request to use explicit system/user prompts and disable streaming.
- Refactor
ResourceService.loadByUrlto useUint8Arrayconsistently and add unit tests for resource loading.
Reviewed changes
Copilot reviewed 7 out of 9 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/pkg/utils/monaco-editor/utils.test.ts | Updates test fixtures for global comment parsing/updating. |
| src/pages/options/routes/AgentProvider.tsx | Changes provider “probe” request to be non-streaming and include system/user prompts. |
| src/app/service/service_worker/resource.ts | Simplifies blob-to-bytes handling by removing redundant Uint8Array conversion. |
| src/app/service/service_worker/resource.test.ts | Adds tests for ResourceService.loadByUrl across text/binary/error cases. |
| src/app/service/agent/core/providers/openai.ts | Adds <think> tag handling in SSE stream content deltas. |
| pnpm-workspace.yaml | Adds workspace-level configuration. |
| package.json | Bumps Rspack and pnpm versions. |
| .gitignore | Ignores .omc. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
| // 跨 chunk 追踪 <think>...</think> 块状态(用于把思考混在 content 里的模型) | ||
| let inThinkBlock = false; |
| // 处理 <think>...</think> 内联标签(reasoning 模型) | ||
| // 思考内容路由为 thinking_delta,避免裸露标签出现在对话里 | ||
| let remaining: string = delta.content; | ||
|
|
||
| while (remaining.length > 0) { | ||
| if (inThinkBlock) { | ||
| // 已在 think 块内,找结束标签 | ||
| const endIdx = remaining.indexOf("</think>"); | ||
| if (endIdx === -1) { | ||
| // 整段都是思考内容 | ||
| onEvent({ type: "thinking_delta", delta: remaining }); | ||
| remaining = ""; | ||
| } else { | ||
| // 结束标签之前是思考内容,之后是正文 | ||
| if (endIdx > 0) { | ||
| onEvent({ type: "thinking_delta", delta: remaining.slice(0, endIdx) }); | ||
| } | ||
| inThinkBlock = false; | ||
| remaining = remaining.slice(endIdx + "</think>".length); | ||
| } | ||
| } else { | ||
| // 不在 think 块内,找开始标签 | ||
| const startIdx = remaining.indexOf("<think>"); | ||
| if (startIdx === -1) { | ||
| // 整段都是正文 | ||
| onEvent({ type: "content_delta", delta: remaining }); | ||
| remaining = ""; | ||
| } else { | ||
| // 开始标签之前是正文,之后进入思考块 | ||
| if (startIdx > 0) { | ||
| onEvent({ type: "content_delta", delta: remaining.slice(0, startIdx) }); | ||
| } | ||
| inThinkBlock = true; | ||
| remaining = remaining.slice(startIdx + "<think>".length); | ||
| } | ||
| } | ||
| } |
| @@ -0,0 +1 @@ | |||
| minimumReleaseAge: 10080 | |||
# Conflicts: # pnpm-lock.yaml
There was a problem hiding this comment.
Pull request overview
该 PR 主要为 Agent 体系补齐对“推理模型(reasoning model)”的兼容:一方面在 OpenAI 兼容的流式解析中将 <think>...</think> 这类内联思考标签路由为 thinking_delta,另一方面在 Options 里的“测试连接”请求中显式关闭流式返回;同时顺带修正了 ResourceService 的二进制读取实现并补充对应单测,并更新了 pnpm/rspack 相关依赖与锁文件。
Changes:
- OpenAI SSE 流解析:识别并拆分
<think>...</think>,将思考内容输出为thinking_delta - Options/AgentProvider 测试连接请求:增加 system/user 提示词并设置
stream: false - ResourceService:直接使用
blobToUint8Array,新增loadByUrl单测;并更新 pnpm/rspack/pnpm-lock 等工程依赖
Reviewed changes
Copilot reviewed 2 out of 3 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/app/service/agent/core/providers/openai.ts | 在 content 增量中解析 <think>...</think> 并转发为 thinking_delta |
| src/pages/options/routes/AgentProvider.tsx | 测试连接请求增加 system/user 消息并显式 stream: false |
| src/app/service/service_worker/resource.ts | 资源加载改用 blobToUint8Array,去掉多余的 ArrayBuffer → Uint8Array 转换 |
| src/app/service/service_worker/resource.test.ts | 新增 ResourceService.loadByUrl 的单测覆盖文本/二进制/异常场景 |
| src/pkg/utils/monaco-editor/utils.test.ts | 测试用例数据微调(axios → moment) |
| pnpm-workspace.yaml | 新增 pnpm workspace 配置(仅包含 minimumReleaseAge) |
| package.json | 升级 rspack 相关依赖版本与 pnpm 版本声明 |
| pnpm-lock.yaml | 锁文件随依赖升级发生大规模更新 |
| .gitignore | 忽略新增的 .omc 目录/文件 |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
| body = JSON.stringify({ | ||
| model: editingModel.model || defaultModel, | ||
| max_tokens: 256, | ||
| messages: [{ role: "user", content: "hi" }], | ||
| messages: [ | ||
| { role: "system", content: systemMessage }, | ||
| { role: "user", content: userMessage }, | ||
| ], | ||
| stream: false, | ||
| }); |
| @@ -196,7 +199,43 @@ export function parseOpenAIStream( | |||
| } | |||
| } | |||
| } else { | |||
| onEvent({ type: "content_delta", delta: delta.content }); | |||
| // 处理 <think>...</think> 内联标签(reasoning 模型) | |||
| // 思考内容路由为 thinking_delta,避免裸露标签出现在对话里 | |||
| let remaining: string = delta.content; | |||
|
|
|||
| while (remaining.length > 0) { | |||
| if (inThinkBlock) { | |||
| // 已在 think 块内,找结束标签 | |||
| const endIdx = remaining.indexOf("</think>"); | |||
| if (endIdx === -1) { | |||
| // 整段都是思考内容 | |||
| onEvent({ type: "thinking_delta", delta: remaining }); | |||
| remaining = ""; | |||
| } else { | |||
| // 结束标签之前是思考内容,之后是正文 | |||
| if (endIdx > 0) { | |||
| onEvent({ type: "thinking_delta", delta: remaining.slice(0, endIdx) }); | |||
| } | |||
| inThinkBlock = false; | |||
| remaining = remaining.slice(endIdx + "</think>".length); | |||
| } | |||
| } else { | |||
| // 不在 think 块内,找开始标签 | |||
| const startIdx = remaining.indexOf("<think>"); | |||
| if (startIdx === -1) { | |||
| // 整段都是正文 | |||
| onEvent({ type: "content_delta", delta: remaining }); | |||
| remaining = ""; | |||
| } else { | |||
| // 开始标签之前是正文,之后进入思考块 | |||
| if (startIdx > 0) { | |||
| onEvent({ type: "content_delta", delta: remaining.slice(0, startIdx) }); | |||
| } | |||
| inThinkBlock = true; | |||
| remaining = remaining.slice(startIdx + "<think>".length); | |||
| } | |||
| } | |||
| } | |||
| // 处理 <think>...</think> 内联标签(reasoning 模型) | ||
| // 思考内容路由为 thinking_delta,避免裸露标签出现在对话里 | ||
| let remaining: string = delta.content; | ||
|
|
||
| while (remaining.length > 0) { | ||
| if (inThinkBlock) { | ||
| // 已在 think 块内,找结束标签 | ||
| const endIdx = remaining.indexOf("</think>"); | ||
| if (endIdx === -1) { | ||
| // 整段都是思考内容 | ||
| onEvent({ type: "thinking_delta", delta: remaining }); | ||
| remaining = ""; | ||
| } else { | ||
| // 结束标签之前是思考内容,之后是正文 | ||
| if (endIdx > 0) { | ||
| onEvent({ type: "thinking_delta", delta: remaining.slice(0, endIdx) }); | ||
| } | ||
| inThinkBlock = false; | ||
| remaining = remaining.slice(endIdx + "</think>".length); | ||
| } | ||
| } else { | ||
| // 不在 think 块内,找开始标签 | ||
| const startIdx = remaining.indexOf("<think>"); | ||
| if (startIdx === -1) { | ||
| // 整段都是正文 | ||
| onEvent({ type: "content_delta", delta: remaining }); | ||
| remaining = ""; | ||
| } else { | ||
| // 开始标签之前是正文,之后进入思考块 | ||
| if (startIdx > 0) { | ||
| onEvent({ type: "content_delta", delta: remaining.slice(0, startIdx) }); | ||
| } | ||
| inThinkBlock = true; | ||
| remaining = remaining.slice(startIdx + "<think>".length); | ||
| } | ||
| } | ||
| } |
|
说起来,现在还有什么模型用 |
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
reasoning 模型通过 content 输出 <think>...</think> 时,token 级拆分可能 让标签跨多个 SSE event 到达(例如上一 chunk 末尾 "<th",下一 chunk 开头 "ink>")。之前的 indexOf 仅在单个 delta 内查找,会把残片当作正文输出。 - 新增 thinkTagCarry 缓冲末尾可能匹配标签前缀的残片 - 流结束([DONE] 或无 [DONE] 自然结束)时 flush carry,避免丢内容 - 补充 5 个单测覆盖单 chunk / 跨 chunk / 逐字符 / 残片 flush 场景
gemma4 吧 |
Checklist / 检查清单
Description / 描述
<think>...</think>在 reasoning model 出现 (gemma4)測試: Unsloth Studio 安裝 unsloth/gemma-4-E2B-it-GGUF
Screenshots / 截图